﻿namespace FODATAC

open System
open System.Net
open Microsoft.Data.Edm
open Microsoft.Data.OData
open System.Collections.Generic
open HTTP
open FODATAC.Query
open FODATAC.Interfaces
open System.Runtime.Serialization
open FODATAC.Reflection
open FODATAC.LinqProvider
open System.Linq
open System.Linq.Expressions
open System.Threading.Tasks
open System.Reflection

type ChangeData = 
    { Name : string
      Value : obj
      Setter : Setter }

type Change = 
    | SinglePrimitive of data : ChangeData
    | CollectionPrimitive of data : ChangeData
    | SingleComplex of data : ChangeData
    | CollectionComplex of data : ChangeData * itemType : Type
    | Entity of data : ChangeData * isAttached : bool

type Session(sessionFactory : ISessionFactory) as self = 
    let entitySet = HashSet<Object>()
    let mutable isDisposed = false
    let mutable currentlySavedEntity = None
    let entitiesByNavigationId = Dictionary<string, Object>()
    //let entitiesById = Dictionary<Object, Object>()
    let entitiesByNavigationIdReverse = Dictionary<Object, string>()
    // let entitiesByIdReverse = Dictionary<Object, Object>()
    let settersByEntity = Dictionary<Object, List<Setter * Object>>()
    let deletedEntities = HashSet<Object>()
    //let loadedAssociations = 
    let getSchemaElement (entityType : System.Type) = sessionFactory.MetaData.GetSchemaElementForEntity(entityType)
    let getKeys (entityType : System.Type) = sessionFactory.MetaData.GetKeys(entityType)
    let guidKey = Guid.NewGuid().ToString()
    let metaData = sessionFactory.MetaData
    
    let getNavigationIdFromEntity entity = 
        let bool, red = entitiesByNavigationIdReverse.TryGetValue entity
        if bool then red
        else failwithf "entity does not exist in the session %A" entity
    
    let getKeyValueString entity = 
        if entity = null then null
        else 
            let entityType = entity.GetType()
            let keys = getKeys entityType
            
            let keyValues = 
                seq { 
                    for name, _ in keys do
                        match findSetter ((entity.GetType()), name) with
                        | Some(Field(fi), _) -> yield fi.GetValue entity
                        | Some(Property(pi), _) -> yield pi.GetValue(entity, null)
                        | _ -> failwithf "property %s can't be find in type %A" name entityType
                }
            sprintf "(%s)" (String.Join(",", 
                                        keyValues |> Seq.mapi (fun i key -> 
                                                         let _, ``type`` = keys.[i]
                                                         if ``type``.IsInt64() then sprintf "%sL" (key.ToString())
                                                         else if ``type``.IsGuid() then sprintf "guid'%s'" (key.ToString())
                                                         else key.ToString())))
    
    let rec getComplexValue (targetType : Type) (value : obj) = 
        let properties = (metaData.GetSchemaElementForComplexType targetType).Properties()
        
        let oDataProps = 
            [ for prop in properties do
                  let setter, x = (findSetter (targetType, prop.Name)).Value
                  let va, _, _ = getValueAndMemberName setter value
                  if va <> null then 
                      let valType = va.GetType()
                      yield getODataProperty valType va prop.Name
                  else yield ODataProperty(Name = prop.Name, Value = va) ]
        ODataComplexValue(Properties = oDataProps)
    and getODataProperty ttype (value : obj) name : ODataProperty = 
        match getGenericCollectionType ttype with
        | Some(col) when value <> null -> 
            let targetPropType = ttype.GenericTypeArguments.[0]
            let kind = metaData.GetTypeKind(targetPropType)
            let collName, collNs = MetaData.GetTypeNameAndNameSpace ttype
//            let name, ns = MetaData.GetTypeNameAndNameSpace targetPropType
            
            let value = 
                match kind with
                | EdmTypeKind.Primitive -> 
                    let items = (value :?> IEnumerable<obj>) |> Seq.map (fun item -> (ODataPrimitiveValue(item)))
                    ODataCollectionValue(Items = items) :> ODataValue
                | EdmTypeKind.Complex -> 
                    let properties = (metaData.GetSchemaElementForComplexType targetPropType).Properties()
                    let items = (value :?> IEnumerable<obj>) |> Seq.map (fun item -> (getComplexValue targetPropType item))
                    ODataCollectionValue(Items = items) :> ODataValue
                | _ -> failwith "unsupported type"
            ODataProperty(Name = name, Value = value)
        | None -> 
            let kind = metaData.GetTypeKind(ttype)
            
            let value = 
                match kind with
                | EdmTypeKind.Primitive -> value
                | EdmTypeKind.Complex -> getComplexValue ttype value :> obj
                | _ -> failwith "unsupported type"
            ODataProperty(Name = name, Value = value)
        | _ -> ODataProperty(Name = name, Value = value)
    
    let getChangesPerEntity (entity : obj) (memberName : option<string>) = 
        let setters = settersByEntity.[entity]
        
        let setters = 
            match memberName with
            | Some(memberName) -> 
                let seq = 
                    seq { 
                        for setter : Setter, obj in setters do
                            let _, setterName, _ = getValueAndMemberName setter entity
                            if setterName = memberName then yield (setter, obj)
                    }
                List(seq)
            | _ -> setters
        
        let keyValuesString = getKeyValueString entity
        
        let setSeq = 
            [ for setter, value in setters do
                  let newValue, memberName, ttype = getValueAndMemberName setter entity
                  match (getGenericCollectionType ttype) with
                  | Some(coll) -> 
                      let ttype = ttype.GenericTypeArguments.[0]
                      let newValueColl = newValue :?> IEnumerable<obj>
                      let oldValueColl = value :?> IEnumerable<obj>
                      if not (newValueColl.SequenceEqual(oldValueColl)) then 
                          match (metaData.GetTypeKind(ttype)) with
                          | EdmTypeKind.Primitive -> 
                              yield Change.CollectionPrimitive({ Name = memberName
                                                                 Value = newValue
                                                                 Setter = setter })
                          | EdmTypeKind.Complex -> 
                              yield Change.CollectionComplex({ Name = memberName
                                                               Value = newValue
                                                               Setter = setter }, ttype)
                          | EdmTypeKind.Entity -> 
                              let adds = newValueColl.Except(oldValueColl)
                              for added in adds do
                                  yield Change.Entity({ Name = memberName
                                                        Value = added
                                                        Setter = setter }, true)
                              let deletes = oldValueColl.Except(newValueColl)
                              for deleted in deletes do
                                  yield Change.Entity({ Name = memberName
                                                        Value = deleted
                                                        Setter = setter }, false)
                          | other -> failwith "unsupported typekind %A" other
                  | None -> 
                      if not (Object.Equals(newValue, value)) then 
                          match (metaData.GetTypeKind(ttype)) with
                          | EdmTypeKind.Primitive -> 
                              yield Change.SinglePrimitive({ Name = memberName
                                                             Value = newValue
                                                             Setter = setter })
                          | EdmTypeKind.Complex -> 
                              yield Change.SingleComplex({ Name = memberName
                                                           Value = newValue
                                                           Setter = setter })
                          | EdmTypeKind.Entity -> 
                              yield Change.Entity({ Name = memberName
                                                    Value = newValue
                                                    Setter = setter }, value = null)
                          | other -> failwith "unsupported typekind %A" other ]
        //  if not isSame then yield name, newValue, setter
        if not (setSeq |> Seq.isEmpty) then Some(entity, keyValuesString, setSeq)
        else None
    
    let getChanges() = 
        let metaData = sessionFactory.MetaData
        [ for entity in entitySet do
              match getChangesPerEntity entity None with
              | Some change -> yield change
              | _ -> () ]
    let handleAction str =
        let isession =  self:>ISession
        let query:IQuery = isession.CreateQuery(str)
        let ta = 
            async{
                let! t =  query.PostAsync([||]) |> Async.AwaitTask |> Async.Ignore
                isession.Dispose()
                return t
            }
        convertToTask<unit> ta :> Task
           
        
    interface ISession with
        member this.CreateQuery str = Query.create (str, sessionFactory.MetaData.Model, sessionFactory.NetworkCredential, this)
        
        member this.Dispose() = 
            isDisposed <- true
            entitiesByNavigationId.Clear()
            entitiesByNavigationIdReverse.Clear()
            entitySet.Clear()
            deletedEntities.Clear()
        member this.CommitAsync() =   "Commit" |> handleAction
        member this.RollbackAsync() = "Rollback" |> handleAction
          
           
        member this.MergeAsync entity =
            let isession =  (this:>ISession)
            let t = 
                async{            
                    let! res = (isession.GetAsync entity) |> Async.AwaitTask
                    if res = null then
                        (isession.SaveAsync entity) |> Async.AwaitIAsyncResult |> Async.Ignore|> ignore
                        return entity
                    else if Object.ReferenceEquals(res,entity) then
                        return entity
                    else 
                        let setters = settersByEntity.[res]
                        for setter, obj in setters do
                            let value ,_,_ = getValueAndMemberName setter entity 
                            setSetterValue setter res value
                        return res}
                   
                        
            t |> convertToTask<'T>
                       
                    

            
                
        member this.Evict(entity : 'T) = 
            if not (entitySet.Contains entity) then failwithf "entity %A is not the session" entity
            deletedEntities.Remove(entity) |> ignore
            let navId = entitiesByNavigationIdReverse.[entity]
            entitiesByNavigationId.Remove navId |> ignore
            entitySet.Remove entity |> ignore
            settersByEntity.Remove entity |> ignore
            ()
        
        member this.GetAsync(entity : 'T) = 
            if entitySet.Contains(entity) then 
                let res = async { return entity }
                convertToTask<'T> res
            else 
                let is = this :> ISession
                let uri = is.GetEntityUri entity
                is.CreateQuery(uri.AbsoluteUri).UniqueResultAsync<'T>()
        
        member this.Contains entity = entitySet.Contains entity
        member this.SessionFactory = sessionFactory
        member this.GuidKey = guidKey
        
        member this.LoadAssociationAsync<'T, 'K when 'T : equality and 'T : null> (entity : 'T) (path : Expression<Func<'T, 'K>>) = 
            let getEntityUri = (this :> ISession).GetEntityUri
            if isDisposed then raise (ObjectDisposedException("session"))
            let exp = (path.Body :?> MemberExpression)
            let memberName = exp.Member.Name
            match getChangesPerEntity entity (Some memberName) with
            | None -> 
                let entityUri = getEntityUri entity
                let uri = Uri(entityUri.AbsoluteUri + "/" + memberName)
                if typeof<System.Collections.IEnumerable>.GetTypeInfo().IsAssignableFrom(typeof<'K>.GetTypeInfo()) then 
                    let genArgu = typeof<'K>.GenericTypeArguments.[0]
                    
                    // this is rather ugly but I couln't work around. Here we have a collection and type of K is ICollection<X>
                    //we can't call tolistasync because we need to extract x out of here then call
                    let op = 
                        async { 
                            let request = ClientHttpRequestMessage(Uri(uri.AbsoluteUri), sessionFactory.NetworkCredential)
                            (request :> IODataRequestMessageAsync).SetHeader("session-guid", guidKey)
                            let! response = request.GetResponseAsync()
                            let res = readResponse (response, (this :> ISessionInternal), sessionFactory.MetaData.Model, genArgu)
                            let setter, _ = (findSetter ((entity.GetType()), memberName)).Value
                            Query.setValue setter entity res true (this :> ISessionInternal) |> ignore
                        }
                    
                    let t = convertToTask<unit> (op)
                    upcast t
                else 
                    let op = 
                        async { 
                            let! res = (this :> ISession).CreateQuery(uri.AbsoluteUri).ToListAsync<'K>() |> Async.AwaitTask
                            let setter, _ = (findSetter ((entity.GetType()), memberName)).Value
                            Query.setValue setter entity (res.FirstOrDefault()) true (this :> ISessionInternal) |> ignore
                        }
                    
                    let t = convertToTask<unit> (op)
                    upcast t
            | Some(_) -> 
                let empty = async { () }
                upcast (convertToTask<unit> empty)
        
        member this.SaveAsync(entity) = 
            if isDisposed then raise (ObjectDisposedException("session"))
            if entitySet.Contains entity then
                let empty = async { () }
                upcast (convertToTask<unit> empty)
            else
                let metaData = sessionFactory.MetaData
                if deletedEntities.Contains entity then failwith "Saved entity is already marked for deletion"
                else 
                    let entityType = entity.GetType()
                    let request : IODataRequestMessageAsync = 
                        upcast ClientHttpRequestMessage
                                   (Uri(sessionFactory.BaseUri, metaData.GetCollectionName(entityType)), sessionFactory.NetworkCredential)
                    request.SetHeader("session-guid", guidKey)
                    request.Method <- ODataConstants.MethodPost
                    use messageWriter = new ODataMessageWriter(request, ODataMessageWriterSettings(), sessionFactory.MetaData.Model)
                    let writer = messageWriter.CreateODataEntryWriter()
                    let schemaElement = getSchemaElement (entityType)
                    let simpleProps = schemaElement.Properties() |> Seq.filter (fun k -> k.PropertyKind <> EdmPropertyKind.Navigation)
                
                    let simpleProperties = 
                        [ for k in simpleProps do
                              let foundSetter = (findSetter (entityType, k.Name))
                              match foundSetter with
                              | Some(setter, ttype) -> 
                                  let value, _, _ = getValueAndMemberName setter entity
                                  yield getODataProperty ttype value k.Name
                              | _ -> ()
                              | _ when isStream entityType k.Name -> ()
                              | _ -> failwith "unspported member %A" k.Name ]
                        |> Seq.toList
                
                    let navigationalProperties = 
                        [ for prop in schemaElement.NavigationProperties() do
                              let setter, value = (findSetter (entityType, prop.Name)).Value
                              let currentValue, _, _ = getValueAndMemberName setter entity
                              if (currentValue <> null) then 
                                  // I never understood why this is the opposite
                                  if prop.Multiplicity() <> EdmMultiplicity.Many then 
                                      yield ODataNavigationLink(Name = prop.Name, Url = Uri(getNavigationIdFromEntity value))
                                  else 
                                      match currentValue with
                                      | :? System.Collections.IEnumerable as coll -> 
                                          for item in coll do
                                              yield ODataNavigationLink
                                                        (Name = prop.Name, Url = Uri(getNavigationIdFromEntity item), IsCollection = Nullable true)
                                      | _ -> 
                                          yield ODataNavigationLink
                                                    (Name = prop.Name, Url = Uri(getNavigationIdFromEntity currentValue), IsCollection = Nullable false) ]
                
                    let name, nameSpace = MetaData.GetTypeNameAndNameSpace(entityType)
                    let entry = new ODataEntry(Properties = simpleProperties, TypeName = nameSpace + "." + name)
                    writer.WriteStart(entry)
                    for navprop in navigationalProperties do
                        writer.WriteStart(navprop : ODataNavigationLink)
                        writer.WriteEntityReferenceLink(ODataEntityReferenceLink(Url = navprop.Url))
                        writer.WriteEnd()
                    writer.WriteEnd()
                    let request = request :?> ClientHttpRequestMessage
                    messageWriter.Dispose()
                    let requestAsync = 
                        async { 
                            use! response = request.GetResponseAsync()
                            currentlySavedEntity <- Some(entity)
                            let enumerator = 
                                (readResponse (response, this, metaData.Model, entityType) :?> System.Collections.IEnumerable).GetEnumerator()
                            enumerator.MoveNext() |> ignore
                            let inserted = enumerator.Current
                            return response
                        }
                    upcast (convertToTask<ClientHttpResponseMessage> requestAsync)
        
        member this.FlushAsync() = 
            if isDisposed then raise (ObjectDisposedException("session"))
            let getEntityUri = (this :> ISession).GetEntityUri
            let changes = getChanges()
            let zeroChanges = changes |> Seq.isEmpty
            if not zeroChanges then 
                let perepareRequest() = 
                    let request : IODataRequestMessageAsync = 
                        upcast ClientHttpRequestMessage(Uri(sessionFactory.BaseUri, "$batch"), sessionFactory.NetworkCredential)
                    request.SetHeader("session-guid", guidKey)
                    request.Method <- ODataConstants.MethodPost
                    request
                
                let writeToRequest (request : IODataRequestMessageAsync) = 
                    use messageWriter = new ODataMessageWriter(request, ODataMessageWriterSettings(), sessionFactory.MetaData.Model)
                    let batchWriter = messageWriter.CreateODataBatchWriter()
                    
                    let prologue() = 
                        batchWriter.WriteStartBatch()
                        batchWriter.WriteStartChangeset()
                    
                    let collectedActions = List()
                    
                    let collectNewValuesForStateTracking entity setter newValue = 
                        let list = settersByEntity.[entity]
                        let pair = list |> Seq.find (fun (s, o) -> s = setter)
                        
                        let f() = 
                            list.Remove pair |> ignore
                            list.Add(setter, newValue)
                        collectedActions.Add(f)
                    
                    let writePrimitiveAndComplexValues (counter) = 
                        let mutable currentCounter = counter
                        for entity, keyValuesString, setSeq in changes do
                            let op = batchWriter.CreateOperationRequestMessage("PUT", getEntityUri entity)
                            op.SetHeader("Content-ID", (currentCounter).ToString())
                            currentCounter <- currentCounter + 1
                            use writer = new ODataMessageWriter(op)
                            let ew = writer.CreateODataEntryWriter()
                            
                            let entry = 
                                new ODataEntry(Properties = [| for change in setSeq do
                                                                   match change with
                                                                   | SinglePrimitive(data) -> 
                                                                       collectNewValuesForStateTracking entity data.Setter data.Value
                                                                       yield ODataProperty(Name = data.Name, Value = data.Value)
                                                                   | SingleComplex(data) -> 
                                                                       let _, _, targetType = getValueAndMemberName data.Setter (data.Value)
                                                                       let valu = getComplexValue targetType, data.Value
                                                                       collectNewValuesForStateTracking entity data.Setter data.Value
                                                                       yield ODataProperty(Name = data.Name, Value = valu)
                                                                   | CollectionPrimitive(data) -> 
                                                                       let items = 
                                                                           (data.Value :?> IEnumerable<obj>) 
                                                                           |> Seq.map (fun item -> (ODataPrimitiveValue(item)))
                                                                       let colValue = ODataCollectionValue(Items = items) :> ODataValue
                                                                       collectNewValuesForStateTracking entity data.Setter data.Value
                                                                       yield ODataProperty(Name = data.Name, Value = colValue)
                                                                   | CollectionComplex(data, targetPropType) -> 
                                                                       let properties = 
                                                                           (metaData.GetSchemaElementForComplexType targetPropType).Properties()
                                                                       let coll = data.Value :?> IEnumerable<obj>
                                                                       let items = coll |> Seq.map (fun item -> getComplexValue targetPropType item)
                                                                       let colValue = (ODataCollectionValue(Items = items) :> ODataValue)
                                                                       collectNewValuesForStateTracking entity data.Setter data.Value
                                                                       yield ODataProperty(Name = data.Name, Value = colValue)
                                                                   | _ -> () |])
                            ew.WriteStart(entry)
                            ew.WriteEnd()
                        currentCounter
                    
                    let writeAssociations (counter) = 
                        let mutable currentCounter = counter
                        for entity, keyValuesString, setSeq in changes do
                            for change in setSeq do
                                let writeTheChange value counter verb navigationName = 
                                    let links = "/$links/" + navigationName //+ (getKeyValueString data.Value))
                                    let absolute : string = (getEntityUri (entity)).AbsoluteUri + links
                                    let uri = Uri(absolute)
                                    let op = batchWriter.CreateOperationRequestMessage(verb, uri)
                                    op.SetHeader("Content-ID", (counter).ToString())
                                    use writer = new ODataMessageWriter(op)
                                    let targetUrl = getEntityUri value
                                    let ref = new ODataEntityReferenceLink(Url = new Uri(targetUrl.AbsoluteUri, UriKind.Absolute))
                                    writer.WriteEntityReferenceLink(ref)
                                
                                let delete value counter verb navigationName = 
                                    let links = "/$links/" + navigationName + (getKeyValueString value)
                                    let absolute : string = (getEntityUri (entity)).AbsoluteUri + links
                                    let uri = Uri(absolute)
                                    let op = batchWriter.CreateOperationRequestMessage(verb, uri)
                                    op.SetHeader("Content-ID", (counter).ToString())
                                
                                match change with
                                | Change.Entity(data, false) -> 
                                    collectNewValuesForStateTracking entity data.Setter data.Value
                                    delete data.Value counter "DELETE" data.Name
                                    currentCounter <- currentCounter + 1
                                | Change.Entity(data, true) -> 
                                    collectNewValuesForStateTracking entity data.Setter data.Value
                                    writeTheChange data.Value counter "POST" data.Name
                                    currentCounter <- currentCounter + 1
                                | _ -> ()
                        currentCounter
                    
                    let executeCollectedData() = collectedActions |> Seq.iter (fun k -> k())
                    
                    let processDeletedEntities (counter) = 
                        let mutable currentCounter = counter
                        for entity in deletedEntities do
                            let op = batchWriter.CreateOperationRequestMessage("DELETE", (this :> ISession).GetEntityUri entity)
                            op.SetHeader("Content-ID", (counter).ToString())
                            currentCounter <- currentCounter + 1
                        deletedEntities.Clear()
                    
                    let epilogue() = 
                        batchWriter.WriteEndChangeset()
                        batchWriter.WriteEndBatch()
                    
                    //flow
                    prologue()
                    let counter = 1
                    let counter = writePrimitiveAndComplexValues counter
                    let counter = writeAssociations counter
                    executeCollectedData()
                    processDeletedEntities (counter)
                    epilogue()
                
                let request = perepareRequest()
                writeToRequest request
                let requestAsync = async { let! response = (request :?> ClientHttpRequestMessage).GetResponseAsync()
                                           return response }
                upcast (convertToTask<ClientHttpResponseMessage> requestAsync)
            else 
                let empty = async { () }
                upcast (convertToTask<unit> empty)
        
        member this.Delete(entity) = 
            if not (entitySet.Contains entity) then failwith "Deleted entity is not in the session"
            else 
                deletedEntities.Add entity |> ignore
                settersByEntity.Remove entity |> ignore
                let navId = entitiesByNavigationIdReverse.[entity]
                entitiesByNavigationIdReverse.Remove(entity) |> ignore
                entitiesByNavigationId.Remove(navId) |> ignore
                entitySet.Remove(entity) |> ignore
        
        member this.Query<'T>(collection) : IQueryable<'T> = this.Query<'T>(collection)
        member this.Query<'T>() : IQueryable<'T> = this.Query<'T>(sessionFactory.MetaData.GetCollectionName typeof<'T>)
        member this.GetEntityUri entity = Uri(sessionFactory.BaseUri, (metaData.GetCollectionName(entity.GetType()) + (getKeyValueString entity)))
    
    member private this.Query<'T>(collection) : IQueryable<'T> = 
        if isDisposed then raise (ObjectDisposedException("session"))
        let odata = LinqProvider(this, sessionFactory.BaseUri.ToString())
        odata.CreateQuery(collection)
    
    interface ISessionInternal with
        member this.SettersByEntity = settersByEntity
        
        member this.AddLoadedEntity navigationId entity = 
            entitiesByNavigationId.Add(navigationId, entity)
        
        member this.AddToReverseDictionary navigationId entity = 
            entitiesByNavigationIdReverse.Add(entity, navigationId)
        member this.AddToSession entity = entitySet.Add entity
        member this.GetLoadedEntity navigationId = 
            match currentlySavedEntity with
            | None -> 
                let bool, res = entitiesByNavigationId.TryGetValue navigationId
                match (bool) with
                | true -> Some(res)
                | _ -> None
            | Some entity -> 
                entitiesByNavigationId.Add(navigationId, entity)
                entitiesByNavigationIdReverse.Add(entity, navigationId)
                currentlySavedEntity <- None
                Some(entity)
